Skip to main content 집밥서선생

Generics in Go

Published: 2023-03-01

본 글은 Golang을 공부하며 주요 내용이라 생각되는 것들을 기록해둔 자료이며, Ubuntu 22.04 LTS 기준으로 작성되었습니다.



Introduction


드디어 Golang의 마지막 챕터에 왔다. 아이 신난다!

개인적으로 Go의 사용성 증대의 마지막 열쇠같은 역할을 한 게 제네릭의 추가라고 생각한다. 내가 Go를 배울까 말까 하다가 결국 배우려고 했던 기점이 바로 이 제네릭의 추가이기도 하다. 그만큼 제네릭이 가져다주는 이점은 매우 크다!


Go에는 다소 느리긴 하지만 새로운 기능이 계속 추가되고 있다. 초기 릴리즈인 1.0과 비교했을 때, 지금의 Go에 이르기까지 세 가지 중요한 변화가 있었다. 각각 1.7의 컨텍스트, 1.11의 모듈, 그리고 1.13의 error wrapping이다.

그리고 다음의 큰 변화는 Go 1.18에 있다. 바로 타입 파라미터의 도입, 즉 제네릭이 도입된 것이다! 이번 챕터에서는 제네릭이 할 수 있는 일, 없는 일을 알아보고 제네릭을 통해 낡은 패턴들을 대체해볼 것이다.



Advantages of Generics

Go는 정적 타입 언어로, 파라미터와 변수의 타입이 컴파일할 때 결정됨을 의미한다. 내장 타입(map, slice, channel)과 함수(len(), cap(), make())는 다양한 concrete type의 값을 수용하거나 반환할 수 있었지만, Go 1.18 이전에는 사용자 정의 타입이나 함수는 허용되지 않았다.

만약 코드가 실행될 때까지 타입이 확인되지 않는 동적 타입 언어에 친숙하다면 제네릭에 대한 문제가 무엇인지, 또는 제네릭이 무엇인지조차도 다소 불분명할 수 있다. 이들을 “타입 파라미터”라고 생각하는게 좋을 듯 하다. 우리는 함수가 호출될 때 타입이 지정된 파라미터를 갖는 함수를 작성하는 것이 편하다. 이를테면 아래 코드의 함수 Min()에서는 파라미터로 두 개의 float64 타입을, 그리고 반환형으로 float64타입을 명시하였다.

func Min(v1, v2 float64) float64 {
    if v1 < v2 {
        return v1
    } else {
        return v2
    }
}

마찬가지로 구조체를 선언할 때 필드의 타입을 명시하여 구조체를 생성한다. 아래 예제의 Nodeint*Node타입의 필드를 갖는다.

type Node struct {
    val *int
    next *Node
}

하지만 파라미터나 구조체의 필드의 특정 유형이 사용될 때까지 명시되지 않은 상태로 함수 또는 구조체를 작성하는 것이 좋을 때도 있다.

가령 int 타입에 대한 바이너리 서치 트리 구조체를 작성한다고 해보자. 이때 float64string에서도 동일하게 동작하며 type safety를 만족하는 구조체를 원할 수도 있다. 가장 먼저 떠오르는 방법은 각각의 타입에 대해 여러 개의 구조체를 작성하는 것이다. 하지만 이 방법은 너무 중복성이 심하고 오류가 발생하기 쉽다.

Go에 제네릭이 없었을 때 중복된 코드를 사용하지 않는 유일한 방법은, 값의 대소를 비교하는 방법이 명시된 인터페이스를 사용하는 방법이였다.

type Orderable interface {
    Order(interface{}) int
}

이 인터페이스를 사용하여, 다음과 같이 Tree를 작성할 수 있다.

type Tree struct {
	val         Orderable
	left, right *Tree
}

func (t *Tree) Insert(val Orderable) *Tree {
	if t == nil {
		return &Tree{val: val}
	}

	switch comp := val.Order(t.val); {
	case comp < 0:
		t.left = t.left.Insert(val)
	case comp > 0:
		t.right = t.right.Insert(val)
	}
	return t
}

그리고 아래와 같이 OrderableInt 타입을 선언하여 int 값을 사용할 수 있다.

type OrderableInt int

func (oi OrderableInt) Order(val interface{}) int {
	return int(oi - val.(OrderableInt))
}

func main() {
	var it *Tree
	it = it.Insert(OrderableInt(5))
	it = it.Insert(OrderableInt(3))
	// etc..
}

이 코드는 제대로 동작하지만, 컴파일러가 데이터 구조에 삽입된 값이 모두 동일한지 확인할 수 없다는 문제가 있다. 가령, 아래와 같이 OrderableString 타입을 선언하고 Order() 메소드를 작성한다.

type OrderableString string

func (os OrderableString) Order(val interface{}) int {
	return strings.Compare(string(os), val.(string))
}

그리고 string을 동일한 Tree에 집어넣는다.

func main() {
	var it *Tree
	it = it.Insert(OrderableInt(5))
	it = it.Insert(OrderableInt(3))
	it = it.Insert(OrderableString("Nope!"))
}

이 코드는 컴파일은 문제 없이 잘 된다. Order() 메소드는 interface{} 파라미터를 사용하여 전달된 값을 나타낸다. 이미 OrderableInt가 포함된 트리에 OrderableString을 삽입해도 컴파일러가 에러를 잡아주지 않는다. 따라서 프로그램을 실행하면 아래와 같은 panic이 발생한다.

$ go run tree-non-generics.go
panic: interface conversion: interface {} is main.OrderableInt, not string

다시 말해 컴파일 타임의 type safety 검사가 무효화되므로, Go의 가장 큰 장점 중 하나를 잃는 셈이다.


하지만 Go에 제네릭이 등장했다! 이제 이러한 고민 없이 여러 타입에 호환되면서도 컴파일 타임에 오류를 찾을 수 있는 코드를 작성할 수 있게 되었다. 실제 코드는 조금 있다가 짜볼 것이다.

제네릭이 없는 데이터 구조는 매우 불편하지만, 실질적인 한계는 함수 작성에 있다. 제네릭이 원래 Go의 일부가 아니였기 때문에, Go의 표준 라이브러리에는 몇 가지 특이한(그리고 좀 불편한) 점이 있다. 가령 math.Max(), math.Min(), math.Mod 등의 함수는 각 타입에 대해 여러 함수를 만들기보다는, float64 타입 하나만 파라미터로 둔다. 이는 float64가 웬만한 산술 타입들을 다 커버할 수 있을 만큼 표현 범위가 충분히 크기 때문이다.

제네릭 없이는 인터페이스별로 지정된 변수의 새로운 인스턴스를 만들 수 없으며, concrete type이 같은 두 파라미터를 동일한 인터페이스 타입이 되도록 명시할 수 없다. 또한 제네릭이 없으면 컴파일 타임의 type safety를 포기해야 하거나, 성능을 포기하고 reflection을 사용해야만 특정 타입의 slice를 처리할 수 있다. (sort.Slice()가 이렇게 설계되었다.)



Generics in Go


Go가 처음 발표된 이래로 제네릭이 추가되어야 한다는 많은 요구가 있었다. 하지만 Go는 빠른 컴파일, 코드의 가독성, 빠른 성능을 강조하며, 제네릭이 포함되면 이 세 장점을 잃을 수밖에 없었다고 한다. 하지만 10년 가량 이 문제를 연구한 끝에 Go 개발팀은 방법을 찾아냈다고 한다.

자료구조인 스택의 예제로, Go에서 제네릭이 어떻게 동작하는지 확인해 보자.

type Stack[T any] struct {
	vals []T
}

func (s *Stack[T]) Push(val T) {
	s.vals = append(s.vals, val)
}

func (s *Stack[T]) Pop() (T, bool) {
	if len(s.vals) == 0 {
		var zero T
		return zero, false
	} else {
		top := s.vals[len(s.vals)-1]
		s.vals = s.vals[:len(s.vals)-1]
		return top, true
	}
}

이 코드엔 몇 가지 주목할 점이 있다.

  1. Stack 타입 선언시 [T any]라고 명시해주었다. 대괄호 안에 타입 파라미터가 배치되며, 마치 일반적인 파라미터처럼 타입명이 먼저 오고, 타입 제약조건이 나중에 온다. 타입 파라미터의 이름으로는 아무 것이나 사용할 수 있지만, 대문자를 사용하는 것이 일반적이다. 그리고 Stack 안에서 []T로 주어진 타입의 slice를 선언한 것을 확인할 수 있다.
  2. 타입 파라미터로 올 수 있는 타입은 인터페이스를 사용하여 명시할 수 있다. 위 예제에서는 새로운 키워드인 any가 사용되었는데, 사실 anyinterface{}와 완전히 동일하다! Go 1.18 이후의 버전을 사용한다면 interface{} 대신 any를 써도 되지만 그건 backward compatibility가 보장되지 않으니 그러지 말도록 하자.
  3. 메소드 선언부를 보면, vals를 선언할 때 T를 썼던 것처럼 파라미터 val의 타입 자리에 타입 파라미터 T가 들어간다. 또한 Receiver 부분에서 Stack 대신 Stack[T]를 참조하였다.
  4. 제네릭을 사용하면 Zero value를 다루기가 살짝 까다로워진다. 이를테면 Pop() 메소드에서 Zero value로 nil 같은 걸 막 반환할 수가 없는게, 만약 타입이 int라면 nil이 Zero value가 아니기 때문이다. 따라서 Zero value를 얻기 위해 변수를 var로 선언하고 이를 반환한다. 정의상 var는 다른 값이 할당되지 않으면 변수를 항상 zero value로 초기화하기 때문이다.

제네릭 타입을 사용하는 것은 제네릭이 아닌 버전을 사용하는 것과 크게 다르지 않다.

func main() {
	var intStack Stack[int]
	intStack.Push(10)
	intStack.Push(12)
	intStack.Push(14)
	v, ok := intStack.Pop()
	fmt.Println(v, ok)
}

유일한 차이점은 Stack 타입의 변수를 선언할 때, 타입에 Stack[int]와 같이 타입 정보 int를 대괄호로 묶어 함께 선언하였다는 점이다.

만약 이 예제에서 다음과 같이 문자열을 intStack에 집어넣는다고 하면, 컴파일러가 감지할 것이다.

intStack.Push("nope")

이 라인을 넣고 컴파일 하면 다음과 같은 에러가 발생한다.

$ go run generic-stack.go
./generic-stack.go:31:16: cannot use "nope" (untyped string constant) as int value in argument to intStack.Push

스택에 값이 존재하는지를 반환하는 메소드를 위 예제의 Stack에 새로 추가해보자.

func (s *Stack[T]) Contains(val T) bool {
	for _, v := range s.vals {
		if v == val {
			return true
		}
	}
	return false
}

하지만 이 메소드를 추가하면 컴파일되지 않는다.

$ go run generic-stack.go
./generic-stack.go:26:6: invalid operation: v == val (type parameter T is not comparable with ==)

interface{}가 어떠한 정보도 제공하지 않듯, any도 마찬가지이다. any는 정보를 저장하고 찾을 때, 그 타입이 어떤 타입인가에 대한 정보밖에는 알려주지 못한다. 이를테면 ==를 사용할 수 있는 타입인지에 대한 정보는 알려줄 수 없다! 그래서 ==를 사용하기 위해서는 any가 아닌 다른 타입을 사용해야 한다.

대부분의 concrete type은 != 또는 ==로 비교가 가능하기 때문에, Go에는 비교가 가능한 타입들을 나타낼 수 있는 인터페이스인 comparable이라는 키워드가 정의되어 있다. 다음과 같이 Stack의 정의에서 anycomparable로 바꿔보자.

type Stack[T comparable] struct {
	vals []T
}

이제 Contains() 메소드를 사용할 수 있다.

func main() {
	var intStack Stack[int]
	intStack.Push(10)
	intStack.Push(12)
	intStack.Push(14)
	//intStack.Push("nope") // this occurs error
	fmt.Println(intStack.Contains(10))
	fmt.Println(intStack.Contains(20))
}

실행 결과는 다음과 같다.

$ go run generic-stack.go
true
false

차후 제네릭을 이용해 바이너리 트리를 만드는 방법을 알아볼 것이다. 이에 앞서 제네릭 함수, 제네릭이 인터페이스와 함께 동작하는 방식, type terms와 같은 다른 추가 개념도 다뤄볼 것이다.



Generic Functions


제네릭으로 함수도 작성할 수 있다. 앞서 언급했듯 모든 타입에 호환되는 map, reduce, filter를 제네릭 없이 구현하기는 다소 어렵지만, 이제 제네릭이 있으니 뚝딱 만들 수 있다.

func Map[T1, T2 any](s []T1, f func(T1) T2) []T2 {
	r := make([]T2, len(s))
	for i, v := range s {
		r[i] = f(v)
	}
	return r
}

func Filter[T any](s []T, f func(T) bool) []T {
	var r []T
	for _, v := range s {
		if f(v) {
			r = append(r, v)
		}
	}
	return r
}

func Reduce[T1, T2 any](s []T1, f func(T2, T1) T2, initial T2) T2 {
	r := initial
	for _, v := range s {
		r = f(r, v)
	}
	return v
}

함수 이름과 파라미터 사이에 타입 파라미터를 배치하면 된다. Map()Reduce()는 두 개의 타입 파라미터가 필요하며, Filter()는 한 개면 된다.

이렇게 정의한 세 함수는 다음과 같이 사용할 수 있다.

func main() {
	words := []string{"One", "Potato", "Two", "Potato"}

	filtered := Filter(words, func(s string) bool {
		return s != "Potato"
	})
	fmt.Println(filtered)

	lengths := Map(filtered, func(s string) int {
		return len(s)
	})
	fmt.Println(lengths)

	sum := Reduce(lengths, func(acc int, cur int) int {
		return acc + cur
	}, 0)
	fmt.Println(sum)
}

실행 결과는 다음과 같다.

$ go run map_filter_reduce.go
[One Two]
[3 3]
6


Generics and Interfaces


anycomparable뿐만 아니라 모든 인터페이스를 타입 제약조건으로 사용할 수 있다. 이를테면, fmt.Stringer를 구현하는 동일한 타입의 필드 두 개를 가진 타입을 만들고 싶다고 가정해보자. 제네릭을 사용하면 컴파일 타임에 이를 적용할 수 있다.

type Pair[T fmt.Stringer] struct {
	Val1 T
	Val2 T
}

또한 타입 파라미터를 인터페이스에도 사용할 수 있다.

type Differ[T any] interface {
	fmt.Stringer
	Diff(T) float64
}

위의 Differfmt.Stringer를 임베드하며, 지정된 타입의 값과 비교하여 float64를 반환하는 Diff() 메소드를 포함하는 인터페이스이다.

이 두 타입을 이용하여 비교 함수를 작성해보자. 함수는 두 개의 Pair 인스턴스를 파라미터로 받고

func FindCloser[T Differ[T]](pair1, pair2 Pair[T]) Pair[T] {
	d1 := pair1.Val1.Diff(pair1.Val2)
	d2 := pair2.Val1.Diff(pair2.Val2)
	if d1 < d2 {
		return pair1
	} else {
		return pair2
	}
}

FindCloserDiffer 인터페이스를 충족시키는 필드가 있는 Pair 인스턴스를 받는다. Pair는 두 필드의 타입이 같고 fmt.Stringer 인터페이스를 충족시켜야 한다. 만약 Pair 인스턴스의 필드가 Differ를 충족시키지 않으면, Pair 인스턴스로 FindCloser를 사용하는 것을 컴파일러가 막을 것이다.


이제 Differ 인터페이스를 충족시키는 몇 개의 타입들을 정의해보자.

type Point2D struct {
	X, Y int
}

func (p2 Point2D) String() string {
	return fmt.Sprintf("(%d, %d)", p2.X, p2.Y)
}

func (p2 Point2D) Diff(from Point2D) float64 {
	x := p2.X - from.X
	y := p2.Y - from.Y
	return math.Sqrt(float64(x*x) + float64(y*y))
}

type Point3D struct {
	X, Y, Z int
}

func (p3 Point3D) String() string {
	return fmt.Sprintf("(%d, %d, %d)", p3.X, p3.Y, p3.Z)
}

func (p3 Point3D) Diff(from Point3D) float64 {
	x := p3.X - from.X
	y := p3.Y - from.Y
	z := p3.Z - from.Z
	return math.Sqrt(float64(x*x) + float64(y*y) + float64(z*z))
}

이들을 사용하는 코드는 이렇게 작성할 수 있다.

func main() {
	pair2Da := Pair[Point2D]{Point2D{1, 1}, Point2D{5, 5}}
	pair2Db := Pair[Point2D]{Point2D{10, 10}, Point2D{15, 5}}
	closer := FindCloser(pair2Da, pair2Db)
	fmt.Println(closer)

	pair3Da := Pair[Point3D]{Point3D{1, 1, 10}, Point3D{5, 5, 0}}
	pair3Db := Pair[Point3D]{Point3D{10, 10, 10}, Point3D{11, 5, 0}}
	closer2 := FindCloser(pair3Da, pair3Db)
	fmt.Println(closer2)
}

그리고 실행하면 다음과 같은 결과를 얻는다.

$ go run generic-interface.go
{(1, 1) (5, 5)}
{(10, 10, 10) (11, 5, 0)}


Type Terms


제네릭을 사용하기 위해서는 연산자를 나타내줘야 한다. 가령 Min() 함수의 제네릭 버전을 작성한다고 하면, <>와 같은 비교 연산자를 사용할 수 있어야 함을 나타내는 제약조건을 명시할 수 있어야 한다. Go에서는 인터페이스 안에 한 개 이상의 type term이 명시된, type element를 사용한다.

type BuiltInOrdered interface {
	string | int | int8 | int16 | int32 | int64 | float32 | float64 |
		uint | uint8 | uint16 | uint32 | uint64 | uintptr
}

위 예제는 인터페이스를 임베딩하는 것처럼 concrete type의 목록을 |로 구분하여 나열하였다. 이 타입 목록은 타입 파라미터로 할당될 수 있으며, 목록의 타입이 지원하는 연산자를 사용할 수 있음을 나타낸다. 이때 목록의 타입에서 모두 사용할 수 있는 연산자만 사용 가능하다. 따라서 위 예제에서는 ==, !=, >, <, >=, <=, + 등이 사용 가능 연산자일 것이다. 주의해야 할 점은, type element에 concrete type의 type term이 있는 인터페이스는 오직 타입 파라미터로 사용된 영역에서만 사용할 수 있다. 얘네를 그 밖의 변수, 필드, 리턴 값, 파라미터로 사용하게 되면 컴파일 에러가 발생한다.

이제 알건 다 알았으니 BuiltInOrdered 를 사용하여 Min()을 제네릭으로 작성해보자.

func Min[T BuiltInOrdered](v1, v2 T) T {
	if v1 < v2 {
		return v1
	} else {
		return v2
	}
}

func main() {
	a := 10
	b := 20
	fmt.Println(Min(a, b))
}

기본적으로 type term은 정확히 매칭된다. 만약 BuiltInOrdered에 명시된 type term 중 하나를 사용자 정의 타입으로 재선언한 후 그 인스턴스로 Min()을 호출하면, 에러가 발생한다.

var myA MyInt = 10
var myB MyInt = 20
fmt.Println(Min(myA, myB))

위 예제를 실행하면 다음과 같은 에러가 발생한다.

$ go run type_terms.go
./type_terms.go:27:17: MyInt does not implement BuiltInOrdered (possibly missing ~ for int in constraint BuiltInOrdered)

에러 메시지를 보면 이 문제의 해결법을 알려준다! 만약 type term의 타입이 사용자 정의 타입에 대해서도 동작하게 하고 싶다면, type term 앞에 ~를 붙이면 된다.

// by putting ~ before type term, it works when the type parameter is a equivalent user-defined type
type BuiltInOrdered interface {
	~string | ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

이제 다시 실행해보면 문제 없이 동작함을 확인할 수 있다.

func main() {
	a := 10
	b := 20
	fmt.Println(Min(a, b))

	// this occurs an error
	var myA MyInt = 10
	var myB MyInt = 20
	fmt.Println(Min(myA, myB))
}

타입 파라미터로 사용되는 인터페이스가 type element와 메소드를 모두 가질 수 있다. 이를테면 어떤 타입이 int와 동등하면서 String() 문자열 메소드가 있어야 충족시킬 수 있는 인터페이스는 다음과 같이 선언할 수 있다.

type PrintableInt interface {
	~int
	String() string
}

하지만 Go의 컴파일러는 실질적으로 충족시키는 게 불가능한 인터페이스도 만들 수 있다는 점을 주의해야 한다. 이를테면 위 예제에서 ~int가 아니라 int라고 적고 작성한다면, 어떠한 타입도 PrintableInt 인터페이스를 충족시킬 수 없기 때문이다. (int는 메소드가 없다)

type ImpossiblePrintableInt interface {
	int
	String() string
}

type ImpossibleStruct[T ImpossiblePrintableInt] struct {
	val T
}

type MyInt int

func (mi MyInt) String() string {
	return fmt.Sprint(mi)
}

컴파일러 관점에서 볼 때, 위 코드의 ImpossiblePrintableInt는 아무런 문제가 없다. 즉, 불가능한 인터페이스를 선언할 때 컴파일러가 에러를 잡아주지는 못한다.

하지만, 이를 사용하려고 할 때 에러를 잡아줄 수는 있다. 불가능한 타입 파라미터를 사용하여 함수나 타입을 선언한다면 컴파일러가 이를 감지하여 컴파일 에러를 발생시킨다.

func main() {
	s := ImpossibleStruct[int]{10}
	s2 := ImpossibleStruct[MyInt]{10}
	fmt.Println(s.val, s2.val)
}

ImpossibleStruct 인스턴스를 만드려고 할 때, 다음과 같은 에러가 발생한다.

$ go run impossible_interface.go
./impossible_interface.go:26:24: int does not implement ImpossiblePrintableInt (missing String method)
./impossible_interface.go:27:25: MyInt does not implement ImpossiblePrintableInt (possibly missing ~ for int in constraint ImpossiblePrintableInt)

type term은 int나 string같은 원시 타입 뿐 아니라 slice, map, array, 또한 채널, 구조체, 함수까지도 가능하다. 따라서 타입 파라미터가 특정한 concrete type과 동등하며 한 개 이상의 메소드를 가질 때 type term을 사용하면 유용할 것이다.



Type Inference and Generics


Go는 := 연산자를 사용할 때 타입 추론을 지원하며, 마찬가지로 제네릭 함수에서도 호출을 단순화하기 위해 타입 추론을 사용한다. 위 예의 Map, Filter, Reduce를 사용할 때 이를 확인할 수 있다.

하지만 특정 상황에서는 타입 추론을 할 수 없으며(타입 파라미터가 리턴값으로만 사용된 경우 등), 그런 경우에는 타입이 반드시 명시되어야 한다. 다음의 예제는 타입 추론을 할 수 없는 경우의 코드이다.

func main() {
	var a int = 10
	//b1 := Convert(a) // occurs error
	b2 := Convert[int, int64](a)
	//var b3 int64 = Convert(a) // occurs error

	fmt.Println(b2)
}

주석 처리한 b1은 타입 추론이 불가능하며, 변수의 타입을 명시한 b3도 오류가 발생한다. 즉 b2처럼 타입 파라미터를 직접 명시해주어야 한다.



Type Elements Limit Constants


type element는 제네릭 타입의 변수에 할당될 수 있는 상수를 지정할 수도 있다. 연산자와 마찬가지로, 상수는 type element에 존재하는 모든 type term에 대해 유효해야 한다.

위에서 BuiltInOrderedInteger의 정의를 다시 보자.

type BuiltInOrdered interface {
	~string | ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

type Integer interface {
	int | int8 | int16 | int32 | int64 |
		uint | uint8 | uint16 | uint32 | uint64
}

BuiltInOrdered에 명시된 모든 type term을 만족시키는 상수는 존재하지 않는다. 따라서 이 제네릭 타입에는 상수를 할당할 수 없다.

한편, Integer의 경우 할당 가능한 범위의 상수가 존재한다. (0~127일 것이다) 하지만 그 범위를 벗어난 수를 할당하려고 하면 에러가 발생한다.

// Invaliid!
func PlusOneThousand[T Integer](in T) T {
	return in + 1000
}

// Valid!
func PlusOneHundred[T Integer](in T) T {
	return in + 100
}

PlusOneThousand()를 컴파일하려 하면 아래와 같은 에러가 발생한다.

$ go run cannot_type_inference.go
./cannot_type_inference.go:16:14: cannot convert 1000 (untyped int constant) to T

원인은 명백하다. 1000이 8비트 정수 범위를 벗어났기 때문이다. 반면 PlusOneHundred()은 문제 없이 컴파일된다.



Generic Functions + Generic Data Structures


다시 바이너리 트리 예제로 돌아와보자. 이제 우리가 배운 것들을 이용하여 모든 concrete type에 적합한 단일 바이너리 트리 타입을 작성해볼 것이다. 이 때 두 값을 비교하고 순서를 알려주는 제네릭 함수가 바이너리 트리에 필요하다는 것을 알아야 한다.

type OrderableFunc[T any] func(t1, t2 T) int

그렇다면 트리의 구현이 약간 달라진다. 먼저, 트리를 NodeTree 두 개의 타입으로 나눠야 한다.

type Tree[T any] struct {
	f    OrderableFunc[T]
	root *Node[T]
}

type Node[T any] struct {
	val         T
	left, right *Node[T]
}

Tree 인스턴스를 생성하는 생성자 함수도 작성해준다.

func NewTree[T any](f OrderableFunc[T]) *Tree[T] {
	return &Tree[T]{
		f: f,
	}
}

Tree의 메소드는 간단하다. 실제 작업을 하는 Node의 메소드를 호출해주기만 하면 된다.

func (n *Node[T]) Add(f OrderableFunc[T], v T) *Node[T] {
	if n == nil {
		return &Node[T]{val: v}
	}
	switch r := f(v, n.val); {
	case r <= -1:
		n.left = n.left.Add(f, v)
	case r >= 1:
		n.right = n.right.Add(f, v)
	}
	return n
}

func (n *Node[T]) Contains(f OrderableFunc[T], v T) bool {
	if n == nil {
		return false
	}
	switch r := f(v, n.val); {
	case r <= -1:
		return n.left.Contains(f, v)
	case r >= 1:
		return n.right.Contains(f, v)
	}
	return true
}

이제 OrderedFunc에 대응하는 함수가 필요하다. BuiltInOrdered를 활용하여 모든 원시 타입이 지원되는 함수를 작성할 수 있다.

type BuiltInOrdered interface {
	~string | ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

func BuiltInOrderable[T BuiltInOrdered](t1, t2 T) int {
	if t1 < t2 {
		return -1
	} else if t1 > t2 {
		return 1
	} else {
		return 0
	}
}

선언한 BuiltInOrderableTree와 함께 사용한 코드는 다음과 같다.

func main() {
	t1 := NewTree(BuiltInOrderable[int])
	t1.Add(10)
	t1.Add(30)
	t1.Add(15)
	fmt.Println(t1.Contains(10))
	fmt.Println(t1.Contains(40))
}

구조체를 정의하여 Tree에 집어넣을 수도 있다. 그러면 정렬 함수를 어떻게 작성하는지가 관건일 듯 하다. 우선 다음과 같이 함수를 작성하는 방법이 있다.

type Person struct {
	Name string
	Age  int
}

func OrderPeople(p1, p2 Person) int {
	out := strings.Compare(p1.Name, p2.Name)
	if out == 0 {
		out = p1.Age - p2.Age
	}
	return out
}

함수 OrderPeople()를 생성 함수에 넘겨주면 PersonTree를 만들 수 있다.

t2 := NewTree(OrderPeople)
t2.Add(Person{"Bob", 30})
t2.Add(Person{"James", 30})
t2.Add(Person{"Bob", 50})
fmt.Println(t2.Contains(Person{"Bob", 30}))
fmt.Println(t2.Contains(Person{"Fred", 20}))

함수를 넘기는 대신 메소드를 넘길 수도 있다. 사실상 메소드도 함수의 일종이라 크게 놀라운 사실은 아니다.

func (p Person) Order(other Person) int {
	out := strings.Compare(p.Name, other.Name)
	if out == 0 {
		out = p.Age - other.Age
	}
	return out
}

마찬가지로 Order()의 method expression을 Tree의 생성 함수에 넘겨주면 된다.

t2 := NewTree(Person.Order)
t2.Add(Person{"Bob", 30})
t2.Add(Person{"James", 30})
t2.Add(Person{"Bob", 50})
fmt.Println(t2.Contains(Person{"Bob", 30}))
fmt.Println(t2.Contains(Person{"Fred", 20}))


Things That Are Left Out


Go는 작은 언어를 지향하며, 일반적으로 다른 언어에 존재하는 제네릭 관련 기능이 Go에는 포함되지 않은 경우가 많다. 이 단락에서는 Go 제네릭의 초기 구현에 없었던 기능들을 소개하고자 한다.

사용자 정의 타입과 기본 타입 둘 다 사용할 수 있는 단일 트리를 만들 수 있었지만, Python, Ruby, C++ 등은 이 문제를 다르게 해결한다. 바로 이를 통해 사용자 정의 타입이 연산자에 대한 동작을 지정할 수 있는 연산자 오버로딩이다. 하지만 Go에는 이 기능이 없기 때문에 사용자 정의 컨테이너 타입에 range를 통해 이터레이트하거나 []를 통해 인덱싱할 수 없다.

Go가 연산자 오버로딩을 지원하지 않는 이유가 있다. 우선 Go에는 연산자가 매우 많다. Go는 연산자 오버로딩 말고도 함수나 메소드의 오버로딩도 지원하지 않으며, 다른 타입에 대해 다른 연산자의 기능을 지정하는 방법이 필요하다. 무엇보다 연산자 오버로딩은 개발자가 코드를 읽고 기호의 정확한 의미를 떠올리기 힘들게 한다. 이를테면 C++의 << 기호는 “bitwise shift left”와 “write value”의 두 가지 의미가 있다. Go는 이러한 가독성 문제를 회피하려 하는 것이다.

Go의 초기 제네릭 구현에서 제외된 또 다른 유용한 기능은 메소드에 대한 추가 타입 파라미터이다. 즉, 메소드는 타입 파라미터를 가질 수 없다. Map, Filter, Reduce 함수를 되돌아보면, 다음과 같은 메소드로 구현된다면 유용할 것이라고 생각할 수 있다.

type FunctionalSlice[T any] []T

func (fs FunctionalSlice[T]) Map[E any](f func(T) E) FunctionalSlice[E] {
	out := make(FunctionalSlice[E], len(fs))
	for i, v := range fs {
		out[i] = f(v)
	}
	return out
}

func (fs FunctionalSlice[T]) Filter(f func(T) bool) FunctionalSlice[T] {
	var out []T
	for _, v := range fs {
		if f(v) {
			out = append(out, v)
		}
	}
	return out
}

func (fs FunctionalSlice[T]) Reduce[E any](f func(E, T) E, start E) E {
	out := start
	for _, v := range fs {
		out = f(out, v)
	}
	return out
}

그렇다면 위 메소드는 다음과 같이 사용할 수 있을 것이다.

words = FunctionalSlice[string]{"One", "Potato", "Two", "Potato"}
sum := words.Filter(func(s string) bool {
	return s != "Potato"
}).Map(func(s string) int {
	return len(s)
}).Reduce(func(acc, cur int) int {
	return acc + cur
})

함수형 프로그래밍 애호가들에겐 아쉽겠지만(ㅠㅠ) Go에서는 이러한 코드가 작동하지 않는다. 메소드의 호출 체인을 만드는 대신, 함수 호출을 중첩하거나 함수를 한 번에 하나씩 호출하고 중간 값을 변수에 할당하는 식으로 가독성이 더 좋은 방법을 사용해야 한다.

또한 가변적인 타입 파라미터도 존재하지 않는다. 가령, 우리가 Reflection에서 다뤘던 주제 중에는 기존 함수의 시간을 재는 wrapper 함수를 작성하는 문제도 있었다. 이러한 경우는 제네릭으로 처리할 수 없기 때문에, 여전히 Reflection으로 처리해주어야 한다. 타입 파라미터를 사용할 때마다 각각의 필요한 타입의 이름을 명시적으로 제공해야 하므로, 타입이 다른 파라미터의 개수로 함수를 나타낼 수 없다.

이외에도 Go의 제네릭에는 다음과 같은 기능들이 제외되었다.

  • Specialization
    함수 또는 메소드는 제네릭 버전 외에 하나 이상의 타입별로 오버로드되지 않는다. 다시 말하지만, Go에는 오버로드가 없다.

  • Currying 다른 제네릭 함수나 타입에 따라, 타입 파라미터 중 일부를 지정하여 함수나 타입을 부분적으로 인스턴스화할 수 없다.

  • Metaprogramming 컴파일시 실행되는 코드를 지정하여 런타임에 실행되는 코드를 생성할 수 없다.



Idiomatic Go and Generics


Go에 제네릭이 추가됨으로써, 기존의 이상적인 코드가 분명히 바뀌었다. 이제 float64로 불특정 산술 타입을 나타내지 않을 것이다. 또한 함수나 자료구조에서 명시되지 않은 타입을 나타낼 때 interface{} 대신 any를 사용하여야 한다. 이제 함수 하나로 다양한 타입의 slice를 처리할 수 있다.

하지만 지금 당장 모든 코드를 타입 파라미터를 사용하게끔 변경할 필요가 없다. 새로운 디자인 패턴이 만들어지더라도 오래된 코드는 동작한다.

제네릭의 성능을 판단하기에는 하직 조금 이르다. Go 1.18의 컴파일러는 이전 버전에 비해 느려졌지만, 이후 릴리즈에서 해결될 문제로 예상된다. 이미 현재 런타임 문제에 관한 연구가 진행되고 있다. 요컨대 향후 버전에서 제네릭이 다듬어질수록 런타임 성능도 개선될 것이다.

목표는 항상 충분히 빠르고 유지보수성이 좋으면서도 우리의 수요를 충족시키는 코드를 작성하는 것이다.



Further Features Unlocked


Go 1.18에 제네릭이 처음 출시되었을 때, 여러 논란이 있었다. 새로운 식별자인 anycomparable이 도입되었지만, 표준 라이브러리의 API는 제네릭을 사용하지 않는다. 기존에 interface{}를 사용하던 API가 any로 바뀐 것 외엔 실질적인 변화는 없다

이후 버전의 표준 라이브러리에서는 일반적인 경우의 타입들을 분류하는 새로운 인터페이스(Orderable같은), 새로운 타입(set, tree, 정렬된 map 등), 새로운 함수가 추가될 것으로 보인다. 이러한 것들을 직접 작성하여 쓰는 것도 좋지만, 표준 라이브러리가 업데이트되면 교체하는 것을 고려해보자.

제네릭은 차후 추가될 기능들의 기본이 될 수 있다. 한가지 가능성은 sum type이다. type element가 타입 파라미터를 대체할 수 있는 타입을 명시하는 데 사용되듯, 인터페이스의 파라미터에도 사용될 수 있을 것이다.

오늘날 Go는 특정 필드가 단일 값이거나, 아니면 값의 list인지 확실히 처리할 수 없는 종류의 JSON을 처리하는 데 문제가 있다. 만약 sum type이 추가된다면, 필드가 문자열인지, 또는 문자열의 slice인지 명시하는 인터페이스를 생성할 수 있을 것이다. 그러면 type switch를 사용하여 모든 유효한 타입을 열거함으로써 type safety를 향상시킬 수 있을 것이다. 이렇게 허용할 한정된 종류의 타입만 명시하는 기능은 Rust나 Swift등 현대 언어에서 enum을 통해 sum types을 사용한다. 현재 Go의 enum 기능은 다소 약하기 때문에, 괜찮은 방법일지라도 아이디어가 평가되고 연구되기까지는 다소 시간이 걸릴 것이다.



후기


이제야 기본적인 Go의 내용이 끝났다.

학업때문에 Go를 잠깐 놓았던 기간을 제외하면, 이 책 내용 끝내는 데만 대략 4개월이 걸린 셈이다. 아무리 원서 읽고 번역하고 블로그에 기록까지 하면서 하는 거라 해도, 다소 오래 걸린 감이 있다.

어떤 프로그래밍 언어를 가장 잘 하는 방법은 그 언어로 아무 토이프로젝트나 해보며 이것저것 부딛혀보는 것이라고들 한다. 전적으로 동의하는 바이고 자바스크립트도 그렇게 배웠지만, Go는 중간에 한 프로젝트라고는 WebRTC 시그널링 서버 하나 만든것 말곤 없다. 이것도 사실상 인터페이스를 사용하지 않은 거라 도움이 엄청 많이 됐을것같진 않다. 아무래도 이것저것 더 많이 해봐야 할것 같다.

Go를 배운 이유가 자바가 싫어서도 있고, MSA에 가장 적합한 언어라고 여겨진다는 점도 있었다. 이렇듯 배움의 시작점이 된 건 다소 별거 아닌 이유였지만, 배우면 배울수록 매력적인 언어라고 느꼈다! 물론 배워야 할 게 아직도 산더미지만 ㅠ

Go의 동시성 패턴이나 분산처리 패턴같은 것도 좀 배워두고 싶은데, 이런 것들은 뭔가 필요성이 느껴질 때 배워야하는 내용들인 것 같다. 그래서 지금은 그러한 필요성을 느끼기 위해, 뭔가 프로젝트같은 것들을 해보려 한다. 학기가 시작해서 프로젝트에 쏟을 시간이 얼마나 될지는 모르겠지만..



References


[

Learning Go Book Cover ](https://learning.oreilly.com/library/view/learning-go/9781492077206/)
[Jon Bodner, 『Learning Go』, O'Reilly Media, Inc.](https://learning.oreilly.com/library/view/learning-go/9781492077206/)

© 2024 JHSeo. All right reserved.